← Back to Learning Journey

State Management Beyond Redux: Modern Patterns

Exploring modern state management solutions - Zustand, Jotai, React Query, and when to use each

The State Management Evolution

Coming back to React development, I expected Redux to still be the go-to solution. Instead, I discovered a landscape of simpler, more flexible tools. Here's what I learned about modern state management.

Understanding State Types

Not all state needs global management:

Local State

// Component-specific state
function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

Server State

// Data from APIs that needs caching, synchronization
const { data: users } = useQuery("users", fetchUsers)

Global UI State

// Theme, modals, sidebars, notifications
const { theme, setTheme } = useThemeStore()

Form State

// Complex forms with validation
const form = useForm({
  /* ... */
})

Zustand: Simple Global State

Perfect for global UI state and small to medium applications:

import create from "zustand"

// Simple store
const useStore = create(set => ({
  count: 0,
  increment: () => set(state => ({ count: state.count + 1 })),
  decrement: () => set(state => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}))

// Usage
function Counter() {
  const { count, increment, decrement } = useStore()
  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  )
}

Complex Zustand Example

import create from "zustand"
import { devtools, persist } from "zustand/middleware"

const useAuthStore = create(
  devtools(
    persist(
      set => ({
        user: null,
        token: null,
        isAuthenticated: false,

        login: async (email, password) => {
          const response = await fetch("/api/login", {
            method: "POST",
            body: JSON.stringify({ email, password }),
          })
          const { user, token } = await response.json()

          set({
            user,
            token,
            isAuthenticated: true,
          })
        },

        logout: () =>
          set({
            user: null,
            token: null,
            isAuthenticated: false,
          }),

        updateUser: updates =>
          set(state => ({
            user: { ...state.user, ...updates },
          })),
      }),
      {
        name: "auth-storage", // localStorage key
        partialize: state => ({
          token: state.token,
          user: state.user,
        }), // Only persist these fields
      }
    ),
    { name: "AuthStore" } // Redux DevTools name
  )
)

Zustand with Immer (Complex State Updates)

import create from "zustand"
import { immer } from "zustand/middleware/immer"

const useCartStore = create(
  immer(set => ({
    items: [],
    total: 0,

    addItem: product =>
      set(state => {
        const existingItem = state.items.find(item => item.id === product.id)

        if (existingItem) {
          existingItem.quantity++
        } else {
          state.items.push({ ...product, quantity: 1 })
        }

        state.total = state.items.reduce(
          (sum, item) => sum + item.price * item.quantity,
          0
        )
      }),

    removeItem: productId =>
      set(state => {
        state.items = state.items.filter(item => item.id !== productId)
        state.total = state.items.reduce(
          (sum, item) => sum + item.price * item.quantity,
          0
        )
      }),

    updateQuantity: (productId, quantity) =>
      set(state => {
        const item = state.items.find(item => item.id === productId)
        if (item) {
          item.quantity = quantity
          state.total = state.items.reduce(
            (sum, item) => sum + item.price * item.quantity,
            0
          )
        }
      }),
  }))
)

React Query: Server State Management

Perfect for API data, caching, and synchronization:

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"

// Fetching data
function UsersList() {
  const {
    data: users,
    isLoading,
    error,
  } = useQuery({
    queryKey: ["users"],
    queryFn: async () => {
      const response = await fetch("/api/users")
      return response.json()
    },
    staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
    cacheTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
  })

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

// Mutations
function CreateUser() {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: async newUser => {
      const response = await fetch("/api/users", {
        method: "POST",
        body: JSON.stringify(newUser),
      })
      return response.json()
    },
    onSuccess: () => {
      // Invalidate and refetch users list
      queryClient.invalidateQueries({ queryKey: ["users"] })
    },
  })

  return (
    <button
      onClick={() =>
        mutation.mutate({ name: "John", email: "john@example.com" })
      }
      disabled={mutation.isLoading}
    >
      {mutation.isLoading ? "Creating..." : "Create User"}
    </button>
  )
}

Advanced React Query Patterns

// Dependent queries
function UserProfile({ userId }) {
  const { data: user } = useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetchUser(userId),
  })

  // Only fetch posts after user is loaded
  const { data: posts } = useQuery({
    queryKey: ["user", userId, "posts"],
    queryFn: () => fetchUserPosts(userId),
    enabled: !!user, // Only run when user exists
  })
}

// Optimistic updates
function LikeButton({ postId }) {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: () => likePost(postId),
    onMutate: async () => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ["post", postId] })

      // Snapshot previous value
      const previousPost = queryClient.getQueryData(["post", postId])

      // Optimistically update
      queryClient.setQueryData(["post", postId], old => ({
        ...old,
        likes: old.likes + 1,
        liked: true,
      }))

      return { previousPost }
    },
    onError: (err, variables, context) => {
      // Rollback on error
      queryClient.setQueryData(["post", postId], context.previousPost)
    },
    onSettled: () => {
      // Refetch to ensure consistency
      queryClient.invalidateQueries({ queryKey: ["post", postId] })
    },
  })

  return <button onClick={() => mutation.mutate()}>Like</button>
}

Jotai: Atomic State Management

Great for fine-grained reactivity:

import { atom, useAtom } from "jotai"

// Atomic state
const countAtom = atom(0)
const doubleCountAtom = atom(get => get(countAtom) * 2)

function Counter() {
  const [count, setCount] = useAtom(countAtom)
  const [doubleCount] = useAtom(doubleCountAtom)

  return (
    <div>
      <p>Count: {count}</p>
      <p>Double: {doubleCount}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  )
}

// Async atoms
const userAtom = atom(async () => {
  const response = await fetch("/api/user")
  return response.json()
})

function UserProfile() {
  const [user] = useAtom(userAtom) // Automatically handles async
  return <div>{user.name}</div>
}

Context API for Theme/UI State

Sometimes the built-in Context is enough:

const ThemeContext = createContext()

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("light")

  const toggleTheme = () => {
    setTheme(prev => (prev === "light" ? "dark" : "light"))
  }

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

export function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) {
    throw new Error("useTheme must be used within ThemeProvider")
  }
  return context
}

Hybrid Approach: Combining Tools

In real applications, you'll use multiple tools:

// Zustand for UI state
const useUIStore = create(set => ({
  sidebarOpen: false,
  toggleSidebar: () => set(state => ({ sidebarOpen: !state.sidebarOpen })),
}))

// React Query for server state
const { data: posts } = useQuery(["posts"], fetchPosts)

// Local state for component-specific state
const [searchQuery, setSearchQuery] = useState("")

// Context for theme
const { theme } = useTheme()

When to Use What

Use Local State When:

  • State is only used in one component
  • State doesn't need to persist
  • State is simple (a few values)

Use Zustand When:

  • You need global UI state (modals, sidebars, theme)
  • Simple state management without boilerplate
  • Medium-sized applications

Use React Query When:

  • Working with server data (APIs)
  • Need caching and synchronization
  • Want automatic refetching and background updates

Use Jotai When:

  • Need fine-grained reactivity
  • Want composable state atoms
  • Building complex state dependencies

Use Context When:

  • Sharing state with a small subtree
  • Infrequent updates
  • Simple theme/preference management

Use Redux When:

  • Very large applications with complex state
  • Need time-travel debugging
  • Team already knows Redux well

Form State: React Hook Form

import { useForm } from "react-hook-form"

function UserForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm()

  const onSubmit = async data => {
    await createUser(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name", { required: "Name is required" })} />
      {errors.name && <span>{errors.name.message}</span>}

      <input
        {...register("email", {
          required: "Email is required",
          pattern: {
            value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
            message: "Invalid email address",
          },
        })}
      />
      {errors.email && <span>{errors.email.message}</span>}

      <button type="submit">Submit</button>
    </form>
  )
}

What I Learned

  1. Not all state needs global management: Start with local state
  2. Different tools for different needs: Server state ≠ UI state
  3. Zustand is great for simple global state: Much less boilerplate than Redux
  4. React Query is essential for server state: Handles caching, refetching, and more
  5. You can combine tools: Use the right tool for each job
  6. Start simple: Add complexity only when needed

The key insight: Modern state management is about using the right tool for the job, not forcing one solution everywhere.

💬 Found this helpful? Let's connect and discuss!

© 2026, Built with ❤️ &Gatsby